iT邦幫忙

2021 iThome 鐵人賽

DAY 28
0

以下是到目前為止的架構圖,已經成功的將 ViewModel 層的全部商業邏輯移到了 Domain 層:

1223A20C-B026-40FB-BF47-3B7C938D8E71.jpg

接下來,將在右邊的 ContextMenu 也開一條從 View 層到 Domain 層的依賴,讓 CoEditorViewModel 不需要認識 ContextMenu,也就是說拿掉刪除、改顏色、改文字的這幾個功能,如此一來,也不需要將 CoEditorViewModel 的函式經過這麼多層傳到 ContextMenuView 才能做事,ContextMenuView 會有獨立的 ViewModel ,所有的事件交給這個 ViewModel 即可,所以,我們就先從建立 ViewModel 開始吧!

class ContextMenuViewModel(
    private val contextMenu: ContextMenu
): ViewModel() {

    val selectedColor = contextMenu.selectedColor
    val colorOptions = contextMenu.colorOptions

    fun onDeleteClicked() {
        contextMenu.onDeleteClicked()
    }

    fun onColorSelected(color: YBColor) {
        contextMenu.onColorSelected(color)
    }

    fun onEditTextClicked() {
        contextMenu.onEditTextClicked()
    }
}

這個 ViewModel 真是意料之外的簡單,幾乎就是個空殼,因為大部分的實作都已經在 Domain 層完成了。有了這個類別有助於我們可以更加的將職責分離,讓 CoEditorViewModel 的職責更加單一,那我們該如何把它串接到 View 呢?讓我們來看看原本的 MenuView 是長怎樣的吧!

@Composable
fun MenuView(
    modifier: Modifier = Modifier,
    selectedColor: YBColor,
    onDeleteClicked: () -> Unit,
    onColorSelected: (YBColor) -> Unit,
    onTextClicked: () -> Unit
) { 

// in EditorScreen...
@Composable
fun EditorScreen(viewModel: EditorViewModel) {
  // ..others
	MenuView(
	    selectedColor = selectingColor,
	    onDeleteClicked = viewModel::onDeleteClicked,
	    onColorSelected = viewModel::onColorSelected,
	    onTextClicked = viewModel::onEditTextClicked
	)
}

原本是由 EditorScreen 這個 Composable function 擁有 MenuView ,同時也會使用 EditorViewModel 當中的函式來與 MenuView 做互動,現在我們要脫離這樣的相依關係,可以怎麼做呢?

還記得 Stateful 跟 Stateless 的觀念嗎?一個有 ViewModel 的 Composable function 就是一個 Stateful 的元件,換句話說,如果我們要讓 ContextMenuView 有 ViewModel 的相依的話,ContextMenuView ****本身就要是一個 Stateful 元件,但同時我又不想破壞現在既有的函式,畢竟現在MenuView 是可以支援預覽的,要快速修改很方便,一但這個函式跟 ViewModel 有連結的話將會無法預覽,所以,我們為何不乾脆做出不同類型的 ContextMenuView 呢?

Stateful & Stateless 同時存在

@Composable
fun StatefulContextMenuView(
    modifier: Modifier = Modifier
) {
    val contextMenuViewModel by LocalViewModelStoreOwner.current!!.viewModel<ContextMenuViewModel>() // [1]
    val selectedColor by contextMenuViewModel.selectedColor.subscribeAsState(initial = YBColor.Aquamarine)
    
    ContextMenuView(
        modifier = modifier,
        selectedColor = selectedColor, 
        allColors = contextMenuViewModel.colorOptions, // [2]
        onDeleteClicked = contextMenuViewModel::onDeleteClicked, 
        onColorSelected = contextMenuViewModel::onColorSelected,
        onTextClicked = contextMenuViewModel::onEditTextClicked
    ) 
}

這裡新增了一個新類別:StatefulContextMenuView ,可以用來跟 ContextMenuView 作區別,其最主要的職責是獲取 ViewModel 以及掌控 ContextMenu 的狀態。另外,在 [1] 這邊出現了一個新的類別:LocalViewModelStoreOwner ,使用這個類別就可以獲取當下的 ViewModelStoreOwner,還記得嗎?在之前介紹 Navigation 的時候有出現過這個類別,以一般的情況下, ViewModelStoreOwner 會是 Activity 或是 Fragment ,但是有時候我們不希望拿到“全域”的 ViewModelStoreOwner ,而是能夠隨著當下的 Scope 一起生、一起死,這個 LocalViewModelStoreOwner 所做的正是這件事,會隨著當下的情況去拿到當下所提供的 ViewModelStoreOwner ,而不是“全域”的那一個。

至於依我們的使用案例來說,這個 ViewModelStoreOwner 會是誰呢?答案就是 EditorScreen 這邊的 backStackEntry :

ReactiveStickyNoteTheme {
    NavHost(navController, startDestination = Screen.Board.route) { 
        composable(Screen.Board.route) { backStackEntry -> // 就是它  
            EditorScreen(
                // ...
            )
        }

想了解更多關於 LocalViewModelStoreOwner 的資訊,可以去看看 Jetpack Compose 的 CompositionLocal 相關文章。像是這個官方介紹:https://developer.android.com/jetpack/compose/compositionlocal


另外在上方程式碼 StatefulContextMenuView 中的 [2] 將 allColor 傳進了 ContextMenuView,這樣做的目的是讓更多的控制權放在 ViewModel ,不要讓 View 層知道需要顯示哪些顏色的選項,以下的程式碼是 Stateless 的 ContextMenuView

@Composable
fun ContextMenuView(
    modifier: Modifier = Modifier,
    selectedColor: YBColor,
    allColors: List<YBColor>,
    onDeleteClicked: () -> Unit,
    onColorSelected: (YBColor) -> Unit,
    onTextClicked: () -> Unit
) {
    var expended by remember {
        mutableStateOf(false)
    }

    Surface(
        modifier = modifier.fillMaxWidth(),
        elevation = 4.dp,
        color = MaterialTheme.colors.surface
    ) {

        Row {
            IconButton(onClick = onDeleteClicked ) {
                val painter = painterResource(id = R.drawable.ic_delete)
                Icon(painter = painter, contentDescription = "Delete")
            }

            IconButton(onClick = onTextClicked ) {
                val painter = painterResource(id = R.drawable.ic_text)
                Icon(painter = painter, contentDescription = "Edit text")
            }

            IconButton(onClick = { expended = true }) {
                Box(modifier = Modifier
                    .size(24.dp)
                    .background(Color(selectedColor.color), shape = CircleShape))

                DropdownMenu(expanded = expended, onDismissRequest = { expended = false }) {
                    for (color in allColors) { 
                        DropdownMenuItem(onClick = {
                            onColorSelected(color)
                            expended = false
                        }) {
                            Box(modifier = Modifier
                                .size(24.dp)
                                .background(Color(color.color), shape = CircleShape))
                        }
                    }
                }
            }
        }
    }
}

CompositionLocal + Stateful

運用 CompositionLocal 與 Stateful 概念的結合還有另一個好處,就是這樣的做法可以任意的控制 Recomposition 所影響的範圍。一般來說,如果使用參數傳遞的方式,任何有經過參數的 Composable function,只要參數的數值有修改過,不管改的範圍有多少,所有的 Composable function 都會再重新 Recompose 一次,這樣對於效能來說會是個負擔:

19F4274B-FD6B-4EDB-ADCC-A28FD8C76E5E.jpg

以上圖為例,A、B、C 都是 Immutable 的 class ,他們彼此之間是組合關係。現在可能在 ViewModel 中因為某個功能的關係,修改了 c 的值,照理來說,我只想要重新執行 ComposableC 就好了,但是由於他們都是 Immutable ,所以 ComposableAComposableB 也會跟著受影響而一起被重新執行了。所以如果需要的話,使用 CompositionLocal 與 Stateful 會是一個不錯的組合技。

表達更加清晰的意圖

在原本的 EditorScreen 中,控制 ContextMenu 還有 Adder Button 顯示狀態的方式是觀察 selectingNote 這個變數,如果該值為空的,就顯示 Adder Button,反之則顯示 ContextMenu,但是這其實算是領域知識的一部分,交給 View 來判斷其實不是很好,而且如果要寫測試的話,測試很難描述出這邊的行為變化,於是比較好的做法還是將這段邏輯放到 Domain 層:

class CoEditor(
    private val noteRepository: NoteRepository
) {

    private val _showContextMenu = BehaviorSubject.createDefault(false)
    private val _showAddButton = BehaviorSubject.createDefault(true)

    val showAdderButton: Observable<Boolean> = _showAddButton.hide()
    val openEditTextScreen: Observable<String> = _openEditTextScreen.hide()

    // 變更選擇狀態時也一起更新選單的顯示狀態
    fun selectNote(noteId: String) {
        if (selectedNoteId.value.isPresent && selectedNoteId.value.get() == noteId) {
            clearSelection()
        } else {
            selectedNoteId.onNext(Optional.of(noteId))
            _showAddButton.onNext(false)
            _showContextMenu.onNext(true)
        }
    }

    fun clearSelection() {
        selectedNoteId.onNext(Optional.empty())
        _showAddButton.onNext(true)
        _showContextMenu.onNext(false)
    }
    
    // ..others
}

以下是更改過後的 View :

fun CoEditorScreen(
    viewModel: EditorViewModel,
    openEditTextScreen: (String) -> Unit
) {
    ...
    // 預設值為 true,什麼都沒做的情況下應該是未選擇狀態
    val showAddButton by viewModel.showAddButton.subscribeAsState(initial = true)
    val showContextMenu by viewModel.showContextMenu.subscribeAsState(initial = false)
   
    // Adder button
    AnimatedVisibility(
	      visible = showAddButton,
	      modifier = Modifier.align(Alignment.BottomEnd)
	  ) { ... }

    // ContextMenu
    AnimatedVisibility(
        visible = showContextMenu,
        modifier = Modifier.align(Alignment.BottomCenter)
    ) { ... }
}

小結

今天又踏出了改變結構的一小步,讀者試著想像一下,如果沒有經過之前的“小步快跑”,這樣的結構改變會是多麽危險,憑空建立一個 ContextMenuViewModel ,同時又要將其商業邏輯做搬移,我們將很難確保這樣巨大的改變不會改壞任何功能。

接著,好好運用 Jetpack Compose 的 Stateful 組件,可以讓我們更方便的達到職責分離的這件事,從現在開始可以轉換思維:不是只有像是 Activity 或是 Fragment 這種以“頁面”為單位的元件才可以建立 ViewModel 的實例,只要是一個夠獨立的 UI 元件,都可以擁有屬於自己的 ViewModel ,讓 ViewModel 不再因為要認識所有 UI 細節而臃腫肥大。

最後,表達清晰的意圖可以大大的增加程式碼的可讀性,有時為了貪圖方便,讓商業邏輯下放到了 View 層,這樣做的結果會使得下一個看程式碼的工程師需要再做一次的“腦內翻譯”,讓每個行為有適合的名稱看似麻煩但是卻是一個划算的交易。


上一篇
Re-architect - Domain Layer (二)
下一篇
Re-architect - StickyNoteView
系列文
Jetpack Compose X Android Architecture X Functional Reactive Programming30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言